5.13. Типы данных и переменные
Типы данных и переменные
Программирование на любом языке начинается с понимания того, как язык представляет информацию внутри программы. В Rust эта задача решается через строгую, но гибкую систему типов, которая сочетает безопасность, производительность и предсказуемость. Переменные и типы данных — это фундаментальные строительные блоки любой программы, и в Rust им уделяется особое внимание. Язык не просто позволяет хранить значения, он требует от программиста чётко определять, как эти значения устроены, сколько памяти они занимают и какие операции над ними допустимы.
Переменные как именованные контейнеры
В Rust переменная — это именованное место в памяти, предназначенное для хранения значения определённого типа. Объявление переменной всегда начинается с ключевого слова let. Это слово сигнализирует компилятору о создании нового имени, связанного с конкретным значением. По умолчанию все переменные в Rust являются неизменяемыми. Это означает, что после присвоения значения переменной его нельзя изменить. Такой подход поощряет функциональный стиль программирования, снижает количество ошибок, связанных с неожиданными изменениями состояния, и делает код более предсказуемым.
Если требуется изменять значение переменной, необходимо явно указать это, добавив ключевое слово mut после let. Например, запись let mut counter = 0; создаёт изменяемую переменную с начальным значением ноль. Такая явность — часть философии Rust: программа должна честно отражать свои намерения. Изменяемость — это не случайное свойство, а осознанное решение, которое программист фиксирует в коде.
Имя переменной в Rust следует правилам идентификаторов: оно может содержать буквы, цифры, символ подчёркивания и символы Unicode, но не может начинаться с цифры. Соглашения сообщества рекомендуют использовать стиль snake_case — все буквы строчные, слова разделяются символами подчёркивания. Это способствует единообразию кода и упрощает его чтение.
Типы данных: скалярные и составные
Rust разделяет типы данных на две большие категории: скалярные и составные. Скалярные типы представляют одно значение. Составные типы позволяют группировать несколько значений в одну единицу.
Скалярные типы
Скалярные типы в Rust включают целые числа, числа с плавающей запятой, логические значения и символы.
Целочисленные типы делятся на знаковые и беззнаковые. Знаковые целые числа могут представлять как положительные, так и отрицательные значения. Беззнаковые — только неотрицательные. Каждый тип имеет фиксированный размер в битах: 8, 16, 32, 64 или 128. Например, i32 — это знаковое 32-битное целое число, а u64 — беззнаковое 64-битное. Существует также специальный тип isize и usize, размер которых зависит от архитектуры системы: 32 бита на 32-битной платформе и 64 бита на 64-битной. Эти типы часто используются для индексации и работы с размерами коллекций.
Числа с плавающей запятой в Rust представлены двумя типами: f32 и f64. Они соответствуют стандарту IEEE 754 и обеспечивают одинарную и двойную точность соответственно. По умолчанию, если тип не указан явно, компилятор выбирает f64, так как он обеспечивает лучшую точность при сопоставимой производительности на большинстве современных процессоров.
Логический тип в Rust называется bool и может принимать два значения: true и false. Он используется в условиях, циклах и логических выражениях. Логические операции, такие как конъюнкция (&&) и дизъюнкция (||), работают с этим типом и возвращают значение того же типа.
Символьный тип char в Rust представляет собой один символ Юникода. Он занимает четыре байта и способен хранить любые символы, включая эмодзи, акцентированные буквы и специальные символы. Это отличает Rust от языков, где символ ограничен ASCII или однобайтовым представлением.
Составные типы
Составные типы объединяют несколько значений в одну структуру. В Rust есть два основных встроенных составных типа: кортежи и массивы.
Кортеж — это упорядоченная последовательность значений, каждое из которых может иметь собственный тип. Кортеж фиксированного размера создаётся путём перечисления значений в круглых скобках. Например, (500, 6.4, 'a') — это кортеж из трёх элементов: целого числа, числа с плавающей запятой и символа. Тип этого кортежа записывается как (i32, f64, char). Доступ к элементам кортежа осуществляется через декомпозицию (деструктуризацию) или по индексу с помощью точки и номера: tuple.0, tuple.1 и так далее. Кортежи удобны для временного группирования связанных данных, особенно когда нужно вернуть несколько значений из функции.
Массив — это упорядоченная коллекция элементов одного типа. В отличие от многих других языков, массивы в Rust имеют фиксированный размер, который определяется во время компиляции. Это означает, что массив не может расти или сокращаться во время выполнения программы. Объявление массива выглядит как список значений в квадратных скобках: [1, 2, 3, 4, 5]. Если все элементы одинаковы, можно использовать сокращённую форму: [3; 5] создаёт массив из пяти троек. Тип массива записывается как [T; N], где T — тип элемента, а N — количество элементов. Массивы хранятся в стеке, что делает их быстрыми и предсказуемыми по памяти, но ограничивает их применение случаями, где размер известен заранее и невелик.
Аннотации типов и вывод типов
Rust обладает мощной системой вывода типов. Это означает, что во многих случаях компилятор может автоматически определить тип переменной на основе присвоенного значения. Например, в строке let x = 5; компилятор выводит, что x имеет тип i32, так как это стандартный целочисленный тип по умолчанию. Однако программист всегда может указать тип явно, используя двоеточие после имени переменной: let x: i32 = 5;.
Явное указание типа полезно в ситуациях, когда значение не даёт однозначной информации о типе, или когда требуется уточнить намерения. Например, при вызове функции, которая принимает несколько возможных типов, аннотация помогает компилятору выбрать правильный вариант. Также аннотации делают код более читаемым, особенно для сложных структур данных.
Владение и время жизни в контексте переменных
Хотя тема владения выходит за рамки простого объявления переменных, она тесно связана с тем, как Rust управляет данными. Каждое значение в Rust имеет владельца — переменную, которая отвечает за его существование. Когда владелец выходит из области видимости, значение автоматически уничтожается. Это правило гарантирует отсутствие утечек памяти без необходимости сборщика мусора.
Для составных типов, содержащих данные на куче (например, строки String или векторы Vec<T>), это означает, что при присвоении одной переменной другой происходит передача владения, а не копирование данных. Исходная переменная становится недействительной. Такое поведение — часть системы безопасности Rust, предотвращающей использование уже освобождённой памяти.
Строки: два мира — str и String
В Rust существует два основных способа работы с текстом: строковый срез (&str) и владеющая строка (String). Эти типы отражают фундаментальный принцип языка — различие между данными, которые только ссылаются на память, и данными, которые управляют своей памятью.
Строковый срез &str — это неизменяемая последовательность байтов в кодировке UTF-8, хранящаяся где-то в памяти. Чаще всего он используется в виде строковых литералов, например "привет". Такие литералы размещаются в секции исполняемого файла и существуют на протяжении всей программы. Срезы не владеют данными, они лишь указывают на них. Это делает их лёгкими и быстрыми для передачи.
Тип String, напротив, представляет собой изменяемую, растущую строку, выделенную в куче. Он владеет своими данными, что позволяет добавлять, удалять или изменять содержимое. Создание строки происходит через функцию String::from("текст") или метод .to_string(). Например:
let mut s = String::from("Привет");
s.push_str(", мир!");
Этот код корректен, потому что s является изменяемой переменной типа String. Попытка изменить строковый срез приведёт к ошибке компиляции, так как срезы по своей природе неизменяемы.
Преобразование между &str и String происходит часто. Любой String можно превратить в &str с помощью взятия ссылки: &s. Обратное преобразование требует выделения памяти и копирования данных, что делается явно через String::from() или .to_owned().
Пользовательские типы данных
Rust предоставляет мощные инструменты для создания собственных типов. Основные из них — структуры (struct) и перечисления (enum).
Структура объединяет несколько значений под одним именем. Каждое поле имеет своё имя и тип. Например:
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
Этот тип описывает пользователя с четырьмя полями. Экземпляр создаётся синтаксисом инициализации структуры:
let user1 = User {
username: String::from("alice"),
email: String::from("alice@example.com"),
sign_in_count: 1,
active: true,
};
Поля структуры доступны через точку: user1.email.
Перечисления позволяют определить тип, который может быть одним из нескольких вариантов. Например:
enum IpAddr {
V4(String),
V6(String),
}
Здесь IpAddr может быть либо IPv4-адресом, либо IPv6-адресом, каждый из которых хранит строку. Более эффективно использовать встроенные целочисленные типы:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(u16, u16, u16, u16, u16, u16, u16, u16),
}
Такой подход экономит память и исключает недопустимые значения.
Особенно важен стандартный перечисляемый тип Option<T>, который выражает наличие или отсутствие значения:
enum Option<T> {
Some(T),
None,
}
Использование Option вместо нулевых указателей — ключевой элемент безопасности Rust. Компилятор заставляет обрабатывать оба случая: когда значение есть (Some) и когда его нет (None). Это предотвращает ошибки, связанные с обращением к несуществующим данным.
Константы и статические переменные
Помимо обычных переменных, Rust поддерживает константы и статические переменные.
Константа объявляется с помощью ключевого слова const. Она всегда неизменяема, её значение должно быть известно во время компиляции, и она не имеет фиксированного адреса в памяти. Константы могут использоваться в любом месте программы и часто применяются для определения глобальных параметров:
const MAX_POINTS: u32 = 100_000;
Имена констант пишутся заглавными буквами с подчёркиваниями.
Статическая переменная объявляется через static. В отличие от констант, она имеет фиксированный адрес в памяти и живёт всё время выполнения программы. Статические переменные могут быть изменяемыми, но работа с ними требует особых мер предосторожности, так как они нарушают правила владения. Изменяемые статические переменные используются редко и только в специфических сценариях, таких как низкоуровневые системы или встраиваемые устройства.
Область видимости и время жизни
Область видимости переменной в Rust начинается с точки её объявления и заканчивается в конце блока, в котором она объявлена. Блок — это любая часть кода, заключённая в фигурные скобки {}. Когда переменная выходит из области видимости, вызывается её деструктор (если он определён), и память освобождается.
Это поведение гарантирует, что ресурсы не утекают. Например, если переменная содержит файловый дескриптор или сетевое соединение, они будут корректно закрыты при выходе из блока.
Время жизни — это механизм, который компилятор использует для проверки, что ссылки всегда указывают на действительные данные. Хотя в простых случаях время жизни выводится автоматически, в сложных функциях может потребоваться явное указание. Например:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Здесь 'a — это параметр времени жизни, который говорит компилятору, что возвращаемая ссылка будет жить столько же, сколько и обе входные ссылки. Это предотвращает возврат ссылки на данные, которые уже уничтожены.
Практические рекомендации
При работе с типами и переменными в Rust стоит придерживаться нескольких принципов:
— Используйте неизменяемые переменные по умолчанию. Делайте переменную изменяемой только тогда, когда это действительно необходимо.
— Предпочитайте строковые срезы (&str) в качестве параметров функций, если не требуется владение строкой. Это делает функции более гибкими.
— Используйте Option<T> вместо магических значений (например, -1 или null) для обозначения отсутствия данных.
— Объявляйте константы для значений, которые не меняются и используются в нескольких местах.
— Избегайте преждевременной оптимизации. Доверяйте системе типов и компилятору — они помогут вам писать безопасный и эффективный код.
Сопоставление с образцом: декларативный доступ к данным
Rust предоставляет мощный механизм сопоставления с образцом (pattern matching), который позволяет декларативно извлекать данные из сложных структур. Этот механизм особенно эффективен при работе с перечислениями, кортежами и структурами.
Оператор match — центральный инструмент сопоставления. Он принимает значение и сравнивает его с набором образцов. Каждый образец описывает возможную форму данных. Например:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn handle_message(msg: Message) {
match msg {
Message::Quit => println!("Выход"),
Message::Move { x, y } => println!("Перемещение в ({}, {})", x, y),
Message::Write(text) => println!("Текст: {}", text),
Message::ChangeColor(r, g, b) => println!("Цвет: R{} G{} B{}", r, g, b),
}
}
Каждая ветка match точно соответствует одному из вариантов перечисления. Компилятор проверяет, что все возможные случаи обработаны, что исключает ошибки, связанные с неполным анализом состояния.
Сопоставление работает не только с перечислениями. Оно применимо к кортежам:
let point = (3, 5);
match point {
(0, 0) => println!("Начало координат"),
(0, y) => println!("На оси Y: {}", y),
(x, 0) => println!("На оси X: {}", x),
(x, y) => println!("Произвольная точка: ({}, {})", x, y),
}
И к структурам:
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 0, y: 7 };
match p {
Point { x: 0, y } => println!("На оси Y: {}", y),
Point { x, y: 0 } => println!("На оси X: {}", x),
Point { x, y } => println!("Точка: ({}, {})", x, y),
}
Во всех этих случаях сопоставление с образцом делает код выразительным и безопасным, избегая ручного доступа к полям или индексам.
Преобразования типов
Rust не выполняет неявных преобразований между типами. Это предотвращает неожиданные потери точности или изменения поведения программы. Все преобразования должны быть явными.
Для числовых типов используется метод .into() или функция as. Метод .into() работает через трейт Into, который гарантирует безопасное преобразование. Например, u8 можно преобразовать в u16 без потерь:
let a: u8 = 5;
let b: u16 = a.into();
Однако обратное преобразование может привести к потере данных. В таких случаях Rust требует использования as, что сигнализирует о возможном риске:
let c: u16 = 1000;
let d: u8 = c as u8; // 1000 не помещается в u8 — результат 232
Такой подход заставляет программиста осознанно принимать решение о преобразовании.
Для строковых типов преобразования также строго контролируются. Преобразование String в &str происходит через взятие ссылки. Обратное преобразование требует владения данными и выполняется через String::from() или .to_string().
Типы и память: стек и куча
Понимание того, где хранятся данные, помогает писать эффективный код. Rust разделяет память на стек и кучу.
Стек — это область памяти с быстрым доступом, работающая по принципу «последним пришёл — первым ушёл». Данные на стеке имеют фиксированный размер, известный во время компиляции. Скалярные типы, кортежи из скаляров, массивы фиксированного размера — всё это хранится на стеке.
Куча — это менее упорядоченная область памяти, используемая для данных переменного размера. Когда размер данных неизвестен заранее или может меняться, они размещаются в куче. Владеющие типы, такие как String и Vec<T>, хранят свои данные в куче, а в стеке оставляют только метаданные: указатель, длину и ёмкость.
Это разделение объясняет, почему присвоение String передаёт владение, а не копирует данные: копирование всей строки было бы дорогостоящим. Вместо этого Rust перемещает указатель, сохраняя производительность и безопасность.
Пример: работа с пользовательскими данными
Рассмотрим пример, объединяющий многие из рассмотренных концепций:
#[derive(Debug)]
struct Person {
name: String,
age: u8,
}
enum Contact {
Email(String),
Phone(String),
}
fn main() {
let person = Person {
name: String::from("Анна"),
age: 30,
};
let contact = Contact::Email(String::from("anna@example.com"));
println!("{:?}", person);
match contact {
Contact::Email(addr) => println!("Электронная почта: {}", addr),
Contact::Phone(num) => println!("Телефон: {}", num),
}
}
Здесь:
— Person — структура с владеющими полями.
— Contact — перечисление, позволяющее хранить один из двух типов контактов.
— #[derive(Debug)] автоматически реализует вывод структуры для отладки.
— match безопасно извлекает данные из перечисления.
— Все переменные неизменяемы, что соответствует рекомендациям.
Этот код демонстрирует, как система типов Rust обеспечивает ясность, безопасность и эффективность одновременно.